Atraskite greitesnį ir efektyvesnį kodą. Išmokite esminių reguliariųjų išraiškų optimizavimo metodų: nuo grįžimo ir godaus/tingaus atitikimo iki variklio derinimo.
Reguliariųjų išraiškų optimizavimas: išsami Regex našumo derinimo analizė
Reguliariosios išraiškos, arba regex, yra nepakeičiamas įrankis šiuolaikinio programuotojo arsenale. Nuo vartotojo įvesties tikrinimo ir žurnalo failų analizės iki sudėtingų paieškos ir pakeitimo operacijų bei duomenų išgavimo, jų galia ir universalumas yra neabejotini. Tačiau ši galia turi paslėptą kainą. Blogai parašyta regex išraiška gali tapti tyliu našumo žudiku, sukelti didelį vėlavimą, procesoriaus šuolius ir, blogiausiais atvejais, sustabdyti jūsų programą. Būtent čia reguliariųjų išraiškų optimizavimas tampa ne tik „pageidautinu“, bet ir kritiškai svarbiu įgūdžiu, kuriant patikimą ir keičiamo dydžio programinę įrangą.
Šis išsamus vadovas leis jums pasinerti į regex našumo pasaulį. Išsiaiškinsime, kodėl iš pažiūros paprastas šablonas gali būti katastrofiškai lėtas, suprasime regex variklių vidinę veiklą ir suteiksime jums galingą principų bei metodų rinkinį, skirtą rašyti reguliariąsias išraiškas, kurios būtų ne tik teisingos, bet ir žaibiškai greitos.
Supraskime „kodėl“: blogos Regex išraiškos kaina
Prieš pradedant nagrinėti optimizavimo metodus, labai svarbu suprasti problemą, kurią bandome išspręsti. Pati rimčiausia našumo problema, susijusi su reguliariosiomis išraiškomis, yra žinoma kaip katastrofiškas grįžimas (Catastrophic Backtracking) – būsena, kuri gali sukelti „Regular Expression Denial of Service“ (ReDoS) pažeidžiamumą.
Kas yra katastrofiškas grįžimas?
Katastrofiškas grįžimas įvyksta, kai regex varikliui užtrunka išskirtinai ilgai rasti atitikmenį (arba nustatyti, kad atitikmens nėra). Tai atsitinka su tam tikro tipo šablonais, taikomais tam tikro tipo įvesties eilutėms. Variklis patenka į painų permutacijų labirintą, bandydamas kiekvieną įmanomą kelią, kad patenkintų šabloną. Žingsnių skaičius gali augti eksponentiškai priklausomai nuo įvesties eilutės ilgio, o tai sukelia reiškinį, panašų į programos užšalimą.
Panagrinėkime šį klasikinį pažeidžiamos regex išraiškos pavyzdį: ^(a+)+$
Šis šablonas atrodo pakankamai paprastas: jis ieško eilutės, sudarytos iš vienos ar daugiau „a“ raidžių. Jis puikiai veikia su eilutėmis kaip „a“, „aa“ ir „aaaaa“. Problema kyla, kai bandome jį pritaikyti eilutei, kuri beveik atitinka, bet galiausiai neatitinka, pavyzdžiui, „aaaaaaaaaaaaaaaaaaaaaaaaaaab“.
Štai kodėl jis toks lėtas:
- Išorinis
(...)+ir vidinisa+abu yra godūs kvantifikatoriai. - Vidinis
a+pirmiausia atitinka visas 27 „a“ raides. - Išorinis
(...)+yra patenkintas šiuo vienu atitikimu. - Tada variklis bando atitikti eilutės pabaigos inkarą
$. Jam nepavyksta, nes yra „b“ raidė. - Dabar variklis privalo grįžti atgal (backtrack). Išorinė grupė atsisako vieno simbolio, todėl vidinis
a+dabar atitinka 26 „a“ raides, o antroji išorinės grupės iteracija bando atitikti paskutinę „a“ raidę. Tai taip pat nepavyksta ties „b“ raide. - Variklis dabar bandys visus įmanomus būdus, kaip padalinti „a“ raidžių eilutę tarp vidinio
a+ir išorinio(...)+. Eilutei, turinčiai N „a“ raidžių, yra 2N-1 būdų ją padalinti. Sudėtingumas yra eksponentinis, o apdorojimo laikas smarkiai išauga.
Ši viena, iš pažiūros nekalta regex išraiška, gali užrakinti CPU branduolį sekundėms, minutėms ar net ilgiau, efektyviai nutraukdama paslaugų teikimą kitiems procesams ar vartotojams.
Reikalo esmė: Regex variklis
Norint optimizuoti regex, turite suprasti, kaip variklis apdoroja jūsų šabloną. Yra du pagrindiniai regex variklių tipai, o jų vidinė veikla lemia našumo charakteristikas.
DFA (Deterministinio baigtinio automato) varikliai
DFA varikliai yra regex pasaulio greičio demonai. Jie apdoroja įvesties eilutę vienu perėjimu iš kairės į dešinę, simbolis po simbolio. Bet kuriuo momentu DFA variklis tiksliai žino, kokia bus kita būsena, remiantis dabartiniu simboliu. Tai reiškia, kad jam niekada nereikia grįžti atgal. Apdorojimo laikas yra tiesinis ir tiesiogiai proporcingas įvesties eilutės ilgiui. Įrankių, kurie naudoja DFA pagrįstus variklius, pavyzdžiai yra tradiciniai Unix įrankiai, tokie kaip grep ir awk.
Privalumai: Itin greitas ir nuspėjamas našumas. Atsparūs katastrofiškam grįžimui.
Trūkumai: Ribotas funkcijų rinkinys. Jie nepalaiko pažangių funkcijų, tokių kaip atgalinės nuorodos (backreferences), žvalgymasis (lookarounds) ar gaudymo grupės (capturing groups), kurios remiasi galimybe grįžti atgal.
NFA (Nedeterministinio baigtinio automato) varikliai
NFA varikliai yra labiausiai paplitęs tipas, naudojamas moderniose programavimo kalbose, tokiose kaip Python, JavaScript, Java, C# (.NET), Ruby, PHP ir Perl. Jie yra „šablonu paremti“ (pattern-driven), o tai reiškia, kad variklis seka šabloną, judėdamas per eilutę. Kai jis pasiekia dviprasmiškumo tašką (pvz., alternatyvą | ar kvantifikatorių *, +), jis išbandys vieną kelią. Jei tas kelias galiausiai nepavyksta, jis grįžta atgal (backtracks) į paskutinį sprendimo tašką ir bando kitą galimą kelią.
Ši grįžimo galimybė daro NFA variklius tokius galingus ir turtingus funkcijomis, leidžiančius kurti sudėtingus šablonus su žvalgymusi ir atgalinėmis nuorodomis. Tačiau tai taip pat yra jų Achilo kulnas, nes būtent šis mechanizmas įgalina katastrofišką grįžimą.
Likusioje šio vadovo dalyje mūsų optimizavimo metodai bus skirti NFA variklio sutramdymui, nes būtent čia programuotojai dažniausiai susiduria su našumo problemomis.
Pagrindiniai NFA variklių optimizavimo principai
Dabar panagrinėkime praktiškus, veiksmingus metodus, kuriuos galite naudoti kurdami didelio našumo reguliariąsias išraiškas.
1. Būkite konkretūs: tikslumo galia
Dažniausias našumo antipatternas yra pernelyg bendrų pakaitos simbolių, tokių kaip .*, naudojimas. Taškas . atitinka (beveik) bet kurį simbolį, o žvaigždutė * reiškia „nulį ar daugiau kartų“. Kartu jie nurodo varikliui godžiai praryti visą likusią eilutę ir tada grįžti atgal po vieną simbolį, kad patikrintų, ar likusi šablono dalis gali atitikti. Tai yra neįtikėtinai neefektyvu.
Blogas pavyzdys (HTML pavadinimo analizė):
<title>.*</title>
Taikant dideliam HTML dokumentui, .* pirmiausia atitiks viską iki failo pabaigos. Tada jis grįš atgal, simbolis po simbolio, kol ras galutinį </title>. Tai yra daug nereikalingo darbo.
Geras pavyzdys (naudojant neigiamą simbolių klasę):
<title>[^<]*</title>
Ši versija yra daug efektyvesnė. Neigiama simbolių klasė [^<]* reiškia „atitikti bet kurį simbolį, kuris nėra '<' nulį ar daugiau kartų“. Variklis juda į priekį, vartodamas simbolius, kol pasiekia pirmąjį '<'. Jam niekada nereikia grįžti atgal. Tai yra tiesioginė, nedviprasmiška instrukcija, kuri duoda didžiulį našumo prieaugį.
2. Įvaldykite godumą ir tingumą: klaustuko galia
Kvantifikatoriai regex išraiškose pagal numatytuosius nustatymus yra godūs. Tai reiškia, kad jie atitinka kuo daugiau teksto, tuo pačiu leisdami bendram šablonui atitikti.
- Godūs:
*,+,?,{n,m}
Bet kurį kvantifikatorių galite paversti tingiu, pridėdami po jo klaustuką. Tingus kvantifikatorius atitinka kuo mažiau teksto.
- Tingūs:
*?,+?,??,{n,m}?
Pavyzdys: paryškintų žymų atitikimas
Įvesties eilutė: <b>Pirmas</b> ir <b>Antras</b>
- Godus šablonas:
<b>.*</b>
Tai atitiks:<b>Pirmas</b> ir <b>Antras</b>..*godžiai prarijo viską iki paskutinio</b>. - Tingus šablonas:
<b>.*?</b>
Tai pirmu bandymu atitiks<b>Pirmas</b>, o jei ieškosite vėl –<b>Antras</b>..*?atitiko minimalų simbolių skaičių, reikalingą, kad likusi šablono dalis (</b>) atitiktų.
Nors tingumas gali išspręsti tam tikras atitikimo problemas, tai nėra sidabrinė kulka našumui. Kiekvienas tingaus atitikimo žingsnis reikalauja, kad variklis patikrintų, ar atitinka kita šablono dalis. Labai konkretus šablonas (kaip neigiama simbolių klasė iš ankstesnio punkto) dažnai yra greitesnis už tingų.
Našumo tvarka (nuo greičiausio iki lėčiausio):
- Konkreti/neigiama simbolių klasė:
<b>[^<]*</b> - Tingus kvantifikatorius:
<b>.*?</b> - Godus kvantifikatorius su daug grįžimo atgal:
<b>.*</b>
3. Venkite katastrofiško grįžimo: įdėtųjų kvantifikatorių sutramdymas
Kaip matėme pradiniame pavyzdyje, tiesioginė katastrofiško grįžimo priežastis yra šablonas, kuriame kvantifikuota grupė turi kitą kvantifikatorių, galintį atitikti tą patį tekstą. Variklis susiduria su dviprasmiška situacija, kai yra keli būdai padalinti įvesties eilutę.
Probleminiai šablonai:
(a+)+(a*)*(a|aa)+(a|b)*, kai įvesties eilutėje yra daug „a“ ir „b“ raidžių.
Sprendimas yra padaryti šabloną nedviprasmišką. Norite užtikrinti, kad varikliui būtų tik vienas būdas atitikti duotą eilutę.
4. Naudokite atomines grupes ir savininkiškus kvantifikatorius
Tai yra vienas iš galingiausių metodų, kaip pašalinti grįžimą iš savo išraiškų. Atominės grupės ir savininkiški kvantifikatoriai sako varikliui: „Kai jau atitikai šią šablono dalį, niekada neatiduok jokių simbolių. Negrįžk atgal į šią išraišką.“
Savininkiški kvantifikatoriai (Possessive Quantifiers)
Savininkiškas kvantifikatorius sukuriamas pridedant + po įprasto kvantifikatoriaus (pvz., *+, ++, ?+, {n,m}+). Juos palaiko tokie varikliai kaip Java, PCRE (PHP, R) ir Ruby.
Pavyzdys: skaičiaus, po kurio eina „a“, atitikimas
Įvesties eilutė: 12345
- Įprasta Regex:
\d+a\d+atitinka „12345“. Tada variklis bando atitikti „a“ ir nepavyksta. Jis grįžta atgal, todėl\d+dabar atitinka „1234“, ir bando atitikti „a“ su „5“. Jis tęsia tai, kol\d+atiduoda visus savo simbolius. Tai daug darbo, kad galiausiai nepavyktų. - Savininkiška Regex:
\d++a\d++savininkiškai atitinka „12345“. Tada variklis bando atitikti „a“ ir nepavyksta. Kadangi kvantifikatorius buvo savininkiškas, varikliui draudžiama grįžti atgal į\d++dalį. Jis iš karto patiria nesėkmę. Tai vadinama „greitu gedimu“ (failing fast) ir yra itin efektyvu.
Atominės grupės (Atomic Groups)
Atominės grupės turi sintaksę (?>...) ir yra plačiau palaikomos nei savininkiški kvantifikatoriai (pvz., .NET, naujesniame Python `regex` modulyje). Jos veikia lygiai taip pat kaip savininkiški kvantifikatoriai, bet taikomos visai grupei.
Regex išraiška (?>\d+)a yra funkciškai lygiavertė \d++a. Galite naudoti atomines grupes, kad išspręstumėte pradinę katastrofiško grįžimo problemą:
Pradinė problema: (a+)+
Atominis sprendimas: ((?>a+))+
Dabar, kai vidinė grupė (?>a+) atitinka „a“ raidžių seką, ji niekada jų neatiduos, kad išorinė grupė bandytų iš naujo. Tai pašalina dviprasmiškumą ir apsaugo nuo eksponentinio grįžimo.
5. Alternatyvų tvarka yra svarbi
Kai NFA variklis susiduria su alternatyva (naudojant | simbolį), jis bando alternatyvas iš kairės į dešinę. Tai reiškia, kad turėtumėte pirmiausia nurodyti labiausiai tikėtiną alternatyvą.
Pavyzdys: komandos analizė
Įsivaizduokite, kad analizuojate komandas ir žinote, kad `GET` komanda pasitaiko 80% atvejų, `SET` – 15%, o `DELETE` – 5%.
Mažiau efektyvu: ^(DELETE|SET|GET)
80% jūsų įvesties duomenų variklis pirmiausia bandys atitikti `DELETE`, nepavyks, grįš atgal, bandys atitikti `SET`, nepavyks, grįš atgal ir galiausiai sėkmingai atitiks `GET`.
Efektyviau: ^(GET|SET|DELETE)
Dabar 80% atvejų variklis gauna atitikmenį pačiu pirmu bandymu. Šis nedidelis pakeitimas gali turėti pastebimą poveikį apdorojant milijonus eilučių.
6. Naudokite negaudančias grupes, kai nereikia gaudyti
Skliaustai (...) regex išraiškose atlieka du dalykus: grupuoja pošablonį ir gaudo tekstą, kuris atitiko tą pošablonį. Šis sugautas tekstas yra saugomas atmintyje vėlesniam naudojimui (pvz., atgalinėse nuorodose kaip `\1` arba išgavimui naudojant kviečiantį kodą). Šis saugojimas turi nedidelę, bet išmatuojamą pridėtinę vertę.
Jei jums reikia tik grupavimo elgsenos, bet nereikia gaudyti teksto, naudokite negaudančią grupę: (?:...).
Gaudanti: (https?|ftp)://([^/]+)
Tai gaudo „http“ ir domeno vardą atskirai.
Negaudanti: (?:https?|ftp)://([^/]+)
Čia mes vis dar grupuojame `https?|ftp`, kad `://` būtų pritaikytas teisingai, bet nesaugome atitikusio protokolo. Tai yra šiek tiek efektyviau, jei jums rūpi tik domeno vardo išgavimas (kuris yra 1 grupėje).
Pažangūs metodai ir specifiniai patarimai varikliams
Žvalgymasis (Lookarounds): galinga, bet naudokite atsargiai
Žvalgymasis (žvalgymasis į priekį (?=...), (?!...) ir žvalgymasis atgal (?<=...), (?) yra nulinio pločio tvirtinimai. Jie tikrina sąlygą, faktiškai nesunaudodami jokių simbolių. Tai gali būti labai efektyvu kontekstui patvirtinti.
Pavyzdys: slaptažodžio tikrinimas
Regex išraiška, tikrinanti slaptažodį, kuriame turi būti skaitmuo:
^(?=.*\d).{8,}$
Tai labai efektyvu. Žvalgymasis į priekį (?=.*\d) nuskaito į priekį, kad įsitikintų, jog egzistuoja skaitmuo, o tada žymeklis grįžta į pradžią. Pagrindinė šablono dalis, .{8,}, tada tiesiog turi atitikti 8 ar daugiau simbolių. Tai dažnai geriau nei sudėtingesnis, vieno kelio šablonas.
Išankstinis apskaičiavimas ir kompiliavimas
Dauguma programavimo kalbų siūlo būdą „kompiliuoti“ reguliariąją išraišką. Tai reiškia, kad variklis vieną kartą išanalizuoja šablono eilutę ir sukuria optimizuotą vidinę reprezentaciją. Jei tą pačią regex išraišką naudojate kelis kartus (pvz., cikle), visada turėtumėte ją sukompiliuoti vieną kartą už ciklo ribų.
Python pavyzdys:
import re
# Sukompiliuokite regex vieną kartą
log_pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')
for line in log_file:
# Naudokite sukompiliuotą objektą
match = log_pattern.search(line)
if match:
print(match.group(1))
To nepadarius, variklis priverčiamas iš naujo analizuoti šablono eilutę kiekvienoje iteracijoje, o tai yra didelis CPU ciklų švaistymas.
Praktiniai įrankiai Regex profiliavimui ir derinimui
Teorija yra puiku, bet pamatyti reiškia patikėti. Šiuolaikiniai internetiniai regex testuotojai yra neįkainojami įrankiai našumui suprasti.
Svetainės, tokios kaip regex101.com, siūlo „Regex Debugger“ arba „žingsnių paaiškinimo“ funkciją. Galite įklijuoti savo regex ir bandomąją eilutę, ir ji pateiks jums žingsnis po žingsnio seką, kaip NFA variklis apdoroja eilutę. Ji aiškiai parodo kiekvieną atitikimo bandymą, nesėkmę ir grįžimą atgal. Tai yra geriausias būdas vizualizuoti, kodėl jūsų regex yra lėta, ir išbandyti aptartų optimizacijų poveikį.
Praktiškas Regex optimizavimo kontrolinis sąrašas
Prieš diegdami sudėtingą regex išraišką, perleiskite ją per šį mentalinį kontrolinį sąrašą:
- Specifiškumas: Ar panaudojau tingų
.*?arba godų.*ten, kur konkretesnė neigiama simbolių klasė, pvz.,[^"\r\n]*, būtų greitesnė ir saugesnė? - Grįžimas atgal: Ar turiu įdėtųjų kvantifikatorių, tokių kaip
(a+)+? Ar yra dviprasmybių, kurios tam tikroms įvestims galėtų sukelti katastrofišką grįžimą? - Savininkiškumas: Ar galiu naudoti atominę grupę
(?>...)arba savininkišką kvantifikatorių*+, kad išvengčiau grįžimo į pošablonį, kurio, žinau, nereikėtų vertinti iš naujo? - Alternatyvos: Ar mano
(a|b|c)alternatyvose labiausiai paplitusi alternatyva yra nurodyta pirma? - Gaudymas: Ar man reikalingos visos mano gaudančios grupės? Ar kai kurias galima konvertuoti į negaudančias grupes
(?:...), kad sumažėtų pridėtinė vertė? - Kompiliavimas: Jei naudoju šią regex cikle, ar aš ją iš anksto sukompiliuoju?
Atvejo analizė: žurnalo analizatoriaus optimizavimas
Sujunkime viską į vieną vietą. Įsivaizduokime, kad analizuojame standartinę žiniatinklio serverio žurnalo eilutę.
Žurnalo eilutė: 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
Prieš (lėta Regex):
^(\S+) (\S+) (\S+) \[(.*)\] "(.*)" (\d+) (\d+)$
Šis šablonas yra funkcionalus, bet neefektyvus. (.*) datos ir užklausos eilutės atveju žymiai grįš atgal, ypač jei yra sugadintų žurnalo eilučių.
Po (optimizuota Regex):
^(\S+) (\S+) (\S+) \[[^\]]+\] "(?:GET|POST|HEAD) ([^ "]+) HTTP/[\d.]+" (\d{3}) (\d+)$
Patobulinimų paaiškinimas:
\[(.*)\]tapo\[[^\]]+\]. Pakeitėme bendrą, grįžtantį atgal.*labai specifine neigiama simbolių klase, kuri atitinka viską, išskyrus uždarantį skliaustą. Grįžti atgal nereikia."(.*)"tapo"(?:GET|POST|HEAD) ([^ "]+) HTTP/[\d.]+". Tai yra didžiulis patobulinimas.- Mes aiškiai nurodome HTTP metodus, kurių tikimės, naudodami negaudančią grupę.
- URL kelią atitinkame su
[^ "]+(vienas ar daugiau simbolių, kurie nėra tarpas ar kabutė) vietoj bendro pakaitos simbolio. - Mes nurodome HTTP protokolo formatą.
(\d+)būsenos kodui buvo sugriežtintas iki(\d{3}), nes HTTP būsenos kodai visada yra trijų skaitmenų.
„Po“ versija yra ne tik žymiai greitesnė ir saugesnė nuo ReDoS atakų, bet ir tvirtesnė, nes griežčiau tikrina žurnalo eilutės formatą.
Išvada
Reguliariosios išraiškos yra dviašmenis kalavijas. Naudojamos atsargiai ir su žiniomis, jos yra elegantiškas sprendimas sudėtingoms teksto apdorojimo problemoms. Naudojamos nerūpestingai, jos gali tapti našumo košmaru. Pagrindinė išvada – reikia atsižvelgti į NFA variklio grįžimo atgal mechanizmą ir rašyti šablonus, kurie kuo dažniau vestų variklį vienu, nedviprasmišku keliu.
Būdami konkretūs, suprasdami godumo ir tingumo kompromisus, pašalindami dviprasmybes su atominėmis grupėmis ir naudodami tinkamus įrankius savo šablonams testuoti, galite paversti savo reguliariąsias išraiškas iš potencialios naštos į galingą ir efektyvų turtą savo kode. Pradėkite profiliuoti savo regex šiandien ir atraskite greitesnę, patikimesnę programą.